iT邦幫忙

2026 iThome 鐵人賽

DAY 11
0
Software Development

ERP 架構師筆記:定義驅動的框架設計系列 第 11

Day 11:資料存取層的連線、Transaction 與查詢條件

  • 分享至 

  • xImage
  •  

Day 11:資料存取層的連線、Transaction 與查詢條件

Day 5 劃過定義驅動那條路的邊界:跨表關連一律單欄位等值、接的一定是實體資料表。報表、統計、批次匯入超出這個範圍,SQL 就自己寫,任何框架都得留這條路。問題出在交界處:「這一段我不做」跟「這一段你全部自己來」是兩回事,而開一個可以塞任意 SQL 字串的入口,等於把它們寫成同一件事。代價不會當場出現,它出現在第二個資料庫、第一段自己拼的 WHERE、第一句插在存檔中途的查詢。

框架在這個交界處放的是 DbAccess,應用要自己下語句時的唯一入口:一句 SQL、幾個要傳的值,沒有連線物件、沒有要記得關的東西、沒有一句字串串接。它省掉的是你不必知道的事,省不掉的有三件,本篇逐一說明:

  1. 連到哪一個資料庫
  2. 查詢條件不是一段字串,是一棵樹
  3. Transaction 由誰開,由誰關

這三件的共同性質:它們橫切在每一句 SQL 上面,答案卻不在那句 SQL 裡。


一、連到哪一個資料庫

痛點:寫一句查詢,第一行程式碼就得回答「連到哪裡」。單一資料庫沒有這個問題,但一套系統同時給好幾家公司用的時候,資料就分散在好幾個資料庫上,而其中一個還會隨著現在是哪一家公司而變。把答案寫在那句查詢旁邊,等於把部署的形狀寫進業務程式碼裡。

框架要的答案是一個資料庫代號。部署那一側有一份清單,一個代號對到一組連線資訊,這一段在案例的 DatabaseSettings.xml 裡:

<DatabaseItem Id="company"
              DatabaseType="SQLite" ConnectionString="Data Source=northwind.db;Cache=Shared" />

清單長什麼樣是部署環境的事實,跟應用怎麼寫沒有關係:FormSchema 裡沒有出現過任何一個資料庫,業務程式碼裡也沒有出現過任何一個代號。

誰擁有這張表,跟它住在哪裡是兩件事

這套框架的資料表有兩種前綴:框架自己的表是 st_,應用的業務表是 ft_

前綴回答的是誰擁有這張表,資料庫分類回答的是它住在哪裡,兩個問題的答案推導不出彼此。

決定性的例子在框架自己的定義裡:內建的部門、員工、角色、角色授權、使用者角色綁定,每一張都是框架前綴,卻都落在公司的資料庫,因為一家公司的員工就是那家公司的資料。前綴沒有因此失效,它回答的是升版時框架動哪一半,只是回答不了位置。把「誰擁有」跟「放在哪裡」綁成同一件事,上面那幾張表就對不上了。

三類資料庫各放什麼

框架把資料庫分成三類:

分類 放的是什麼
common 使用者、session、公司清單、定義儲存、跨節點的快取通知
company 一家公司自己的資料:業務表,加上部門、員工、角色那幾張
log 操作、登入與異動的記錄

分成幾類,跟實際上有幾個資料庫是兩回事。三類可以共用一個資料庫,也可以各自獨立;company 在多公司部署下是一家一個,log 還可以再依年份切開,當年可寫、往年唯讀。這些全寫在 DatabaseItem 那一份清單裡,一份 FormSchema 都不必跟著改。

路由發生在哪裡

框架把它宣告在型別上。Repository 在建構時就交代自己讀寫哪一類:框架的系統類別直接寫定 commonlog,綁表單的那個由 FormSchema 自己宣告,兩者都在那一刻解析成一個實體資料庫代號。

commonlog 這兩類不必問 session,代號是固定的,只有 company 要先知道現在進到哪一家。這個不對稱是刻意的:登入本身發生在還沒有 session 的時候,而登入成功與失敗都要留下記錄。

服務兩家公司時,company 這一類就是兩筆:

<DatabaseItem Id="company001" CategoryId="company"
              DatabaseType="SQLServer" ConnectionString="Server=db01;Database=northwind001" />
<DatabaseItem Id="company002" CategoryId="company"
              DatabaseType="SQLServer" ConnectionString="Server=db01;Database=northwind002" />

進到第一家,Order 那份 FormSchema 宣告的分類是 company,Repository 解出來的代號就是 company001;換第二家進來,同一支程式碼、同一份定義,解出來的是 company002。中間那一跳由公司自己的資料帶著:st_company 上有一欄記著這家公司用哪一個代號。

兩個決定各擋掉一種麻煩:

  • 宣告在型別上 → 看這個型別就知道它動哪一個資料庫,不必去翻它每個方法。
  • 建構時就解析 → 路由錯了,在碰到任何一筆資料之前就失敗。session 過期、還沒進任何公司、公司資料查不到,三種都在物件建出來的那一刻丟出來。

應用那一側看到的就是這兩行的差別:

CreateDbAccess()                          // 代號由框架解出來的
CreateDbAccess("company001")              // 你自己回答「連哪裡」

第二行框架也提供,因為總有應用真的知道自己要連哪一個。第一行沒有任何地方可以填錯,第二行有。

為什麼是代號,不是連線字串

上面兩行的共同點容易滑過去:它們拿的都是資料庫代號,不是連線字串。整個框架的資料存取,沒有一個入口是收連線字串的。兩個後果:

  • 第一個前面已經講完了:代號在建構的當下就解得出來,連線字串只能由呼叫端自己捧著。
  • 第二個在連線集區上。ADO.NET 的集區以連線字串為鍵分開,兩條字串只要差一個字(多一個空白、參數順序不同、有沒有寫某個選項)就是兩個集區,各自算各自的上限。所以「每個呼叫端自己組連線字串」的代價是集區會依呼叫端的手氣裂成好幾份,而症狀出現在資料庫那一側:連線數莫名偏高,應用這邊卻覺得自己沒開幾條。

框架把它收成一份:連線資訊(資料庫種類、驅動工廠、連線字串)依代號快取,同一個代號永遠是同一條字串、也就永遠是同一個集區;設定改了就整批清掉重讀。

框架自己不做集區。真正的集區是各家驅動的,框架只做兩件事:不製造多餘的連線字串、開了的連線要還回去。第二件在兩側長得不一樣:

// 開發者這一側:看得到的只有這一行
var status = CreateDbAccess().ExecuteScalar("SELECT status FROM ft_order WHERE sys_rowid = {0}", rowId);

// 框架那一側:每次執行開一條,離開這個範圍就還回集區
using var scope = CreateScope();

每次執行都是「開一條、用完還回去」,不是抓著一條連線不放。單機開發完全感覺不到,幾十個並行請求進來的時候,集區才輪得過來。


二、查詢條件不是一段字串,是一棵樹

痛點:查詢畫面八個欄位,使用者只填兩個,你要組出只含那兩個條件的 WHERE。寫法通常是字串串接加一串條件判斷。串接是注入的來源,而注入真正的麻煩不在它難修,在於每加一個查詢畫面就有機會重新犯一次。

框架把查詢條件做成一個模型,而且這個模型住在定義層。位置比內容重要:定義層是其他每一層都認得的那一層,所以使用者填的條件、API 合約傳的條件、BO 收到的條件、Repository 拿到的條件、五家資料庫各自組語句時讀的條件,從頭到尾是同一個型別,中間一次轉換都沒有。

模型本身很小,只有兩種東西:

var filter = FilterGroup.All(
    FilterCondition.Equal("status", "Confirmed"),
    FilterCondition.Between("order_date", from, to),
    FilterGroup.Any(
        FilterCondition.Equal("ref_customer_id", customerId),
        FilterCondition.In("employee_rowid", myTeam)));
  • 單一條件:欄位名、比較方式、一個值(範圍比較另外帶第二個值)。比較方式是封閉清單:等於、不等於、四種大小比較、相似比對、屬於集合、介於兩值之間、開頭是、結尾是、包含。
  • 群組:AllAny,而群組本身也可以是另一個群組的成員。

做成樹不是為了好看:條件真正的形狀本來就是樹,拉平成字串之後就只剩一段文字,而每一個下游都得自己再解析一次才拿得回結構。

一棵樹上的節點也不一定來自同一個地方:使用者填的是一批,權限那一層算出的資料範圍是另一批,兩批接在同一棵樹上;跨越前後端邊界時還有一層會逐個節點走過去,把時間值換算過來。同一個模型有好幾個彼此不相干的產生者與消費者,而它們之間不需要任何轉接。

值走參數,欄位名走定義

這棵樹翻成 SQL 的時候,值跟欄位名走的是兩條不同的路:

  • 值一律變成參數:每遇到一個值就交給參數收集器,換回一個參數名字放進語句,值本身永遠不會出現在 SQL 文字中。
  • 欄位名不能這樣處理(資料庫不接受用參數當欄位名),所以走另一條:先對照定義換成那張表的哪一欄,再套上那一家的識別符引號,名字裡原本就有的引號字元也在這一步一併跳脫。刪除那條路徑更嚴格,名字不在該表欄位清單裡直接拒絕,指向別張表、或指向不是實體欄的顯示欄,同樣拒絕。

參數化擋得住值,擋不住欄位名。

一個只做了參數化、欄位名照樣串進去的實作,從外面看已經像是處理過了。

不讓呼叫端把 SQL 送進來,跟不讓呼叫端把算式送進來是同一條線:兩者都等於在伺服器上開一個執行入口。所以呼叫端能表達的東西是一棵樹,不是一段字串。

應用自己下語句那一側也一樣。值的位置一律用佔位符標出來:

CreateDbAccess().ExecuteScalar(
    "SELECT MAX(sys_id) FROM ft_order WHERE sys_id LIKE {0}", "ORD-202608-%");

送出去的那一刻,{0} 被換成一個參數名:SQL Server 上是 @p0,Oracle 上是 :p0,差的就是昨天那張對照表的參數前綴那一欄。ORD-202608-% 整串掛在那個參數上。

這個介面收的是值,不是拼好的字串:要把值串進語句文字裡,你得先繞過這個方法。Day 5 那句 UPDATE 異動整列所有欄位,不是只挑改動過的那幾個,跟這裡是同一個形狀:用設計讓一整類寫法不成立,比用規範要求大家別那樣寫可靠得多。


三、Transaction 由誰開,由誰關

痛點:一張單據存下去不是一句 SQL。主檔一句,明細有幾列就有幾句,這些句子要嘛一起算數、要嘛一起不算。不在同一個 transaction 裡,主檔寫進去了而其中一句失敗,留下的就是一張明細不齊的單。這種幽靈資料不會有任何提示,要等下次打開它才發現,而資料庫也不會替你擋(Day 3 講過框架不建外鍵約束)。這不是特例,是每一次存檔。

框架驅動的存檔自己開一條 transaction:主檔先、明細後,全部成功才提交,任何一句失敗就整批回滾。刪除反過來,明細先,理由相同,不留下對不起來的另一半。應用看不到那一條,也不必管。

需要自己回答的只有手寫那一段,框架給兩個入口。多句一起下的時候裝成一批,transaction 一樣由框架開與關:

var batch = new DbBatchSpec { UseTransaction = true };
batch.Commands.Add(new DbCommandSpec(DbCommandKind.NonQuery,
    "UPDATE ft_product SET units_in_stock = units_in_stock - {0} WHERE sys_rowid = {1}", qty, productRowId));
batch.Commands.Add(/* 這一批的其餘語句 */);
CreateDbAccess().ExecuteBatch(batch);

已經有一條在跑的時候(例如存檔中途要再寫一筆什麼),把那條 transaction 交進去,語句就跑在它上面:

CreateDbAccess().Execute(spec, transaction);

讓第二個入口成立的,是第一節末尾「開了的連線要還回去」背後那條完整的規則:

連線如果是框架自己開的,用完就關;如果是外面交給它的,只負責在必要時打開,用完不關,因為它不擁有那條連線。

前半句管的是連線還不還得回集區,後半句管的是手寫語句能不能併進既有那一條。同一條規則同時回答了這兩個問題,而它們平常看起來毫不相干。

這裡只講機制,不講語意。擴充點該擺在 transaction 的哪一邊、擺錯會有什麼後果、要讓所有擴充點都在裡面又要付什麼代價,是 Day 14 整篇的題目。


回到 Northwind

八張表單裡只有訂單需要應用接手,而資料存取那一側只有兩句 SQL,都在 OrderRepository 裡。前面兩節各用了其中一句;讀資料庫裡的訂單狀態那一句,同時帶著第一節的代號與第二節的佔位符。

這一整支類別裡:

  • 沒有連線字串、沒有資料庫代號、沒有連線物件
  • 沒有一個 try / finally
  • 沒有一句字串串接

有的只有一句 SQL、一個 {0} 和一個要傳的值。


一句 SQL,加一個代號

應用最後寫下去的就是一句 SQL 加幾個要傳的值。連哪一個資料庫由一個代號回答,而代號在 Repository 被建出來的那一刻就解完了;代號後面那組連線資訊由框架收成一份,同一個代號永遠對到同一個集區;查詢條件是一棵樹,值走參數;transaction 由框架開與關,要併進去的時候把手上那一條交給它。

三件事都還在,只是不在你寫的那一行裡,而它們本來就不該在那裡。連哪一個資料庫是部署形狀的事,條件長什麼樣是前後端合約的事,跑在哪一條 transaction 裡是呼叫脈絡的事,沒有一件屬於那一句 SQL。寫查詢的人只回答跟他的查詢有關的問題,其餘的每一次都會拿到同一個答案。

明天換一個方向:那上千份定義本身,每次請求都重讀一次的話,前面十天省下來的成本會在這裡全部還回去。


本系列同步發表於 HackMD,完整目錄


上一篇
Day 10:多資料庫支援:CRUD 與結構升級
下一篇
Day 12:定義與資料快取的跨節點失效
系列文
ERP 架構師筆記:定義驅動的框架設計12
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言